dashboard-content.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. 'use client';
  2. import { useEffect, useState } from 'react';
  3. import { useRouter } from 'next/navigation';
  4. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
  5. import { Button } from '@/components/ui/button';
  6. import { Input } from '@/components/ui/input';
  7. import { Label } from '@/components/ui/label';
  8. import { useToast } from '@/hooks/use-toast';
  9. import { useTranslations } from "next-intl";
  10. import { User, Mail, Calendar, LogOut, Edit, Save, X, Coins, ImageIcon, Plus, Minus, Crown } from 'lucide-react';
  11. import { useAuth } from "@/components/providers"
  12. interface UserInfo {
  13. id: string;
  14. email: string;
  15. username: string | null;
  16. isEmailVerified: boolean;
  17. credits: number;
  18. subscriptionCredits: number;
  19. subscriptionStatus: string | null;
  20. subscriptionPlan: string | null;
  21. subscriptionStartDate: string | null;
  22. subscriptionEndDate: string | null;
  23. }
  24. interface CreditActivity {
  25. id: string;
  26. type: string;
  27. description: string;
  28. creditAmount: number | null;
  29. metadata: string | null;
  30. createdAt: string;
  31. }
  32. interface DashboardContentProps {
  33. locale: string;
  34. }
  35. export default function DashboardContent({ locale }: DashboardContentProps) {
  36. const { user, isLoading, refreshUser } = useAuth()
  37. const router = useRouter()
  38. const t = useTranslations("dashboard")
  39. const { toast } = useToast()
  40. const tErrors = useTranslations("auth.errors")
  41. const tCredit = useTranslations('credit_description')
  42. const [isEditing, setIsEditing] = useState(false)
  43. const [editUsername, setEditUsername] = useState('')
  44. const [isSaving, setIsSaving] = useState(false)
  45. const [activities, setActivities] = useState<CreditActivity[]>([])
  46. const [isLoadingActivities, setIsLoadingActivities] = useState(true)
  47. const [isSubscribing, setIsSubscribing] = useState(false)
  48. useEffect(() => {
  49. const fetchActivities = async () => {
  50. try {
  51. const response = await fetch('/api/user/activities?limit=10', {
  52. method: 'GET',
  53. credentials: 'include',
  54. })
  55. if (response.ok) {
  56. const data = await response.json()
  57. setActivities(data.activities || [])
  58. } else {
  59. console.error('获取活动记录失败:', response.status)
  60. }
  61. } catch (error) {
  62. console.error('获取活动记录出错:', error)
  63. } finally {
  64. setIsLoadingActivities(false)
  65. }
  66. }
  67. fetchActivities()
  68. }, [])
  69. // 格式化时间
  70. const formatDate = (dateString: string) => {
  71. const date = new Date(dateString)
  72. const now = new Date()
  73. const diffTime = Math.abs(now.getTime() - date.getTime())
  74. const diffMinutes = Math.ceil(diffTime / (1000 * 60))
  75. const diffHours = Math.ceil(diffTime / (1000 * 60 * 60))
  76. const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
  77. if (diffMinutes < 60) {
  78. return `${diffMinutes} ${locale === 'zh' ? '分钟前' : 'minutes ago'}`
  79. } else if (diffHours < 24) {
  80. return `${diffHours} ${locale === 'zh' ? '小时前' : 'hours ago'}`
  81. } else if (diffDays < 7) {
  82. return `${diffDays} ${locale === 'zh' ? '天前' : 'days ago'}`
  83. } else {
  84. return date.toLocaleDateString(locale === 'zh' ? 'zh-CN' : 'en-US', {
  85. year: 'numeric',
  86. month: 'short',
  87. day: 'numeric',
  88. hour: '2-digit',
  89. minute: '2-digit',
  90. })
  91. }
  92. }
  93. // 获取活动图标
  94. const getActivityIcon = (type: string) => {
  95. switch (type) {
  96. case 'credit_deduct':
  97. return <Minus className="h-4 w-4 text-red-500" />
  98. case 'credit_add':
  99. return <Plus className="h-4 w-4 text-green-500" />
  100. case 'registration_bonus':
  101. return <Plus className="h-4 w-4 text-green-500" />
  102. case 'image_generation':
  103. case 'image_edit':
  104. case 'multi_image_edit':
  105. return <ImageIcon className="h-4 w-4 text-blue-500" />
  106. case 'login':
  107. return <User className="h-4 w-4 text-gray-500" />
  108. default:
  109. return <Coins className="h-4 w-4 text-gray-400" />
  110. }
  111. }
  112. // 获取活动类型描述
  113. const getActivityTypeText = (type: string) => {
  114. switch (type) {
  115. case 'credit_deduct':
  116. return t('activityTypes.credit_deduct')
  117. case 'credit_add':
  118. return t('activityTypes.credit_add')
  119. case 'image_generation':
  120. case 'image_edit':
  121. case 'multi_image_edit':
  122. return t('activityTypes.image_generation')
  123. case 'login':
  124. return t('activityTypes.login')
  125. case 'registration_bonus':
  126. return t('activityTypes.registration_bonus')
  127. default:
  128. return t('activityTypes.other')
  129. }
  130. }
  131. // 格式化活动描述
  132. const formatActivityDescription = (activity: CreditActivity) => {
  133. const { description } = activity
  134. // 如果是翻译键格式
  135. if (description.startsWith('credit_description.')) {
  136. const key = description.replace('credit_description.', '')
  137. // 处理特殊的图片编辑格式: credit_description.image_edit:具体内容
  138. if (key.startsWith('image_edit:')) {
  139. const content = key.replace('image_edit:', '')
  140. return `${tCredit('image_edit')}: ${content}`
  141. }
  142. // 处理多图编辑格式: credit_description.multi_image_edit:具体内容
  143. if (key.startsWith('multi_image_edit:')) {
  144. const content = key.replace('multi_image_edit:', '')
  145. return `${tCredit('image_edit')}: ${content}`
  146. }
  147. // 处理多图编辑的默认格式,显示为"图片编辑"而不是"多图编辑"
  148. if (key === 'multi_image_edit') {
  149. // 尝试从metadata中获取prompt信息
  150. try {
  151. const metadata = activity.metadata ? JSON.parse(activity.metadata) : null
  152. if (metadata && metadata.prompt) {
  153. return `${tCredit('image_edit')}: ${metadata.prompt}`
  154. }
  155. } catch (e) {
  156. // 忽略JSON解析错误
  157. }
  158. return tCredit('image_edit')
  159. }
  160. // 直接翻译键
  161. try {
  162. if (key === 'registration_bonus') {
  163. return tCredit('registration_bonus')
  164. } else if (key === 'background_removal') {
  165. return tCredit('background_removal')
  166. } else if (key === 'image_edit') {
  167. return tCredit('image_edit')
  168. } else if (key === 'subscription_activated') {
  169. return tCredit('subscription_activated')
  170. } else if (key === 'purchase_credits') {
  171. return tCredit('purchase_credits')
  172. } else if (key === 'subscription_expired') {
  173. return tCredit('subscription_expired')
  174. }
  175. } catch (error) {
  176. console.log('Translation not found for key:', key)
  177. }
  178. }
  179. // 兼容旧格式,直接返回描述
  180. return description
  181. }
  182. const handleEditProfile = () => {
  183. setIsEditing(true)
  184. }
  185. const handleCancelEdit = () => {
  186. setIsEditing(false)
  187. setEditUsername(user?.username || '')
  188. }
  189. const handleSaveProfile = async () => {
  190. if (!user) return
  191. setIsSaving(true)
  192. try {
  193. const response = await fetch('/api/auth/update-profile', {
  194. method: 'PUT',
  195. headers: {
  196. 'Content-Type': 'application/json',
  197. },
  198. body: JSON.stringify({
  199. username: editUsername.trim() || null,
  200. }),
  201. })
  202. if (response.ok) {
  203. const updatedData = await response.json()
  204. refreshUser()
  205. setIsEditing(false)
  206. // 重新获取活动记录
  207. const fetchActivitiesAgain = async () => {
  208. try {
  209. const response = await fetch('/api/user/activities?limit=10', {
  210. method: 'GET',
  211. credentials: 'include',
  212. })
  213. if (response.ok) {
  214. const data = await response.json()
  215. setActivities(data.activities || [])
  216. }
  217. } catch (error) {
  218. console.error('获取活动记录出错:', error)
  219. }
  220. }
  221. fetchActivitiesAgain()
  222. toast({
  223. title: t('profileUpdated'),
  224. description: t('profileUpdatedDesc'),
  225. })
  226. } else {
  227. const errorData = await response.json()
  228. toast({
  229. title: t('updateFailed'),
  230. description: errorData.error || t('updateError'),
  231. variant: 'destructive',
  232. })
  233. }
  234. } catch (error) {
  235. toast({
  236. title: t('updateFailed'),
  237. description: t('networkError'),
  238. variant: 'destructive',
  239. })
  240. } finally {
  241. setIsSaving(false)
  242. }
  243. }
  244. const handleLogout = async () => {
  245. try {
  246. const response = await fetch('/api/auth/logout', {
  247. method: 'POST',
  248. })
  249. if (response.ok) {
  250. toast({
  251. title: t('logoutSuccess'),
  252. description: t('logoutSuccess'),
  253. })
  254. router.push(`/${locale}`)
  255. router.refresh()
  256. } else {
  257. toast({
  258. title: t('logoutFailed'),
  259. description: t('logoutError'),
  260. variant: 'destructive',
  261. })
  262. }
  263. } catch (error) {
  264. toast({
  265. title: t('logoutFailed'),
  266. description: tErrors('networkError'),
  267. variant: 'destructive',
  268. })
  269. }
  270. }
  271. const handleSubscribe = async () => {
  272. setIsSubscribing(true)
  273. try {
  274. const response = await fetch('/api/create-checkout-session', {
  275. method: 'POST',
  276. headers: {
  277. 'Content-Type': 'application/json',
  278. },
  279. body: JSON.stringify({
  280. locale,
  281. }),
  282. })
  283. const data = await response.json()
  284. if (!response.ok) {
  285. // 处理翻译键错误消息
  286. const errorMessage = data.error === 'alreadySubscribed'
  287. ? '您已有活跃订阅,无需重复订阅'
  288. : data.error || '创建支付会话失败'
  289. throw new Error(errorMessage)
  290. }
  291. // 跳转到 Stripe Checkout 页面
  292. if (data.url) {
  293. window.location.href = data.url
  294. }
  295. } catch (error: any) {
  296. console.error('Error creating checkout session:', error)
  297. toast({
  298. title: '订阅失败',
  299. description: error.message || '创建支付会话时发生错误',
  300. variant: 'destructive',
  301. })
  302. } finally {
  303. setIsSubscribing(false)
  304. }
  305. }
  306. // 如果正在加载,显示加载状态
  307. if (isLoading) {
  308. return (
  309. <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
  310. <div className="text-center">
  311. <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
  312. <p className="text-gray-600 dark:text-gray-400">{t("loading")}</p>
  313. </div>
  314. </div>
  315. )
  316. }
  317. // 如果未登录,重定向到登录页
  318. if (!user) {
  319. router.push(`/${locale}/auth/login`)
  320. return null
  321. }
  322. return (
  323. <div className="bg-gray-50 py-8">
  324. <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
  325. <div className="mb-8">
  326. <h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
  327. <p className="mt-2 text-gray-600">{t('welcome')},{user?.username || t('user')}!</p>
  328. </div>
  329. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
  330. <Card>
  331. <CardHeader className="pb-2">
  332. <CardTitle className="text-sm font-medium">{t('personalInfo')}</CardTitle>
  333. </CardHeader>
  334. <CardContent>
  335. <div className="space-y-3">
  336. <div className="flex items-start space-x-2">
  337. <Mail className="h-4 w-4 text-gray-500 mt-0.5 flex-shrink-0" />
  338. <span className="text-sm text-gray-700 break-all leading-relaxed">{user?.email}</span>
  339. </div>
  340. <div className="flex items-center space-x-2">
  341. <User className="h-4 w-4 text-gray-500 flex-shrink-0" />
  342. <span className="text-sm text-gray-700 truncate">{user?.username || t('nameNotSet')}</span>
  343. </div>
  344. <div className="flex items-center space-x-2">
  345. <Calendar className="h-4 w-4 text-gray-500 flex-shrink-0" />
  346. <span className={`text-xs px-2 py-1 rounded-full flex-shrink-0 ${
  347. user?.isEmailVerified
  348. ? 'bg-green-100 text-green-800'
  349. : 'bg-red-100 text-red-800'
  350. }`}>
  351. {user?.isEmailVerified ? t('verified') : t('unverified')}
  352. </span>
  353. </div>
  354. </div>
  355. </CardContent>
  356. </Card>
  357. <Card className="col-span-1">
  358. <CardHeader className="bg-gradient-to-r from-orange-500 to-red-500 text-white rounded-t-lg">
  359. <CardTitle className="text-base font-medium">{t('creditBalance')}</CardTitle>
  360. </CardHeader>
  361. <CardContent className="p-4">
  362. <div className="space-y-3">
  363. {/* 永久积分 */}
  364. <div>
  365. <div className="text-xl font-bold text-orange-600">
  366. {user?.credits || 0}
  367. </div>
  368. <p className="text-xs text-gray-500">{t('permanentCredits')}</p>
  369. </div>
  370. {/* 订阅积分 */}
  371. {user?.subscriptionStatus === 'active' && (
  372. <div>
  373. <div className="text-xl font-bold text-blue-600">
  374. {user?.subscriptionCredits || 0}
  375. </div>
  376. <p className="text-xs text-gray-500">{t('subscriptionCredits')}</p>
  377. </div>
  378. )}
  379. {/* 总积分 */}
  380. <div className="border-t pt-2">
  381. <div className="text-lg font-semibold text-green-600">
  382. {(user?.credits || 0) + (user?.subscriptionCredits || 0)}
  383. </div>
  384. <p className="text-xs text-gray-500">{t('totalCredits')}</p>
  385. </div>
  386. </div>
  387. </CardContent>
  388. </Card>
  389. <Card>
  390. <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
  391. <CardTitle className="text-sm font-medium">{t('subscriptionStatus')}</CardTitle>
  392. </CardHeader>
  393. <CardContent>
  394. <div className="space-y-3">
  395. {user?.subscriptionStatus === 'active' ? (
  396. <>
  397. <div className="flex items-center space-x-2">
  398. <Crown className="h-4 w-4 text-yellow-500" />
  399. <span className="text-sm font-medium text-green-600">
  400. Pro {t('membershipActive')}
  401. </span>
  402. </div>
  403. <div className="text-xs text-gray-500">
  404. {t('expiresAt')}: {user?.subscriptionEndDate ?
  405. new Date(user.subscriptionEndDate).toLocaleDateString(locale === 'zh' ? 'zh-CN' : 'en-US')
  406. : t('unknown')}
  407. </div>
  408. <div className="text-xs text-gray-500">
  409. {t('nextBilling')}: {user?.subscriptionEndDate ?
  410. new Date(user.subscriptionEndDate).toLocaleDateString(locale === 'zh' ? 'zh-CN' : 'en-US')
  411. : t('unknown')}
  412. </div>
  413. </>
  414. ) : (
  415. <>
  416. <p className="text-sm text-gray-600">
  417. {t('freeVersion')}
  418. </p>
  419. <p className="text-xs text-gray-500">
  420. {t('upgradeToGetMoreFeatures')}
  421. </p>
  422. </>
  423. )}
  424. {/* 订阅按钮 */}
  425. <Button
  426. className={`w-full text-sm ${
  427. user?.subscriptionStatus === 'active'
  428. ? 'bg-gray-400 hover:bg-gray-500 cursor-not-allowed'
  429. : 'bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700'
  430. } text-white`}
  431. onClick={handleSubscribe}
  432. disabled={isSubscribing || user?.subscriptionStatus === 'active'}
  433. size="sm"
  434. >
  435. <Crown className="h-4 w-4 mr-2" />
  436. {isSubscribing ? t('processing') : user?.subscriptionStatus === 'active' ? t('subscribedPro') : t('subscribePro')}
  437. </Button>
  438. </div>
  439. </CardContent>
  440. </Card>
  441. <Card>
  442. <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
  443. <CardTitle className="text-sm font-medium">{t('quickActions')}</CardTitle>
  444. </CardHeader>
  445. <CardContent>
  446. <div className="space-y-2">
  447. <Button
  448. variant="outline"
  449. className="w-full justify-start"
  450. size="sm"
  451. onClick={handleEditProfile}
  452. >
  453. <Edit className="h-4 w-4 mr-2" />
  454. {t('editProfile')}
  455. </Button>
  456. <Button
  457. variant="outline"
  458. className="w-full justify-start text-red-600 hover:text-red-700"
  459. size="sm"
  460. onClick={handleLogout}
  461. >
  462. <LogOut className="h-4 w-4 mr-2" />
  463. {t('logout')}
  464. </Button>
  465. </div>
  466. </CardContent>
  467. </Card>
  468. </div>
  469. {/* 编辑个人资料卡片 */}
  470. {isEditing && (
  471. <Card className="mb-8">
  472. <CardHeader>
  473. <CardTitle>{t('editProfileTitle')}</CardTitle>
  474. <CardDescription>
  475. {t('editProfileDesc')}
  476. </CardDescription>
  477. </CardHeader>
  478. <CardContent>
  479. <div className="space-y-4">
  480. <div className="space-y-2">
  481. <Label htmlFor="editUsername">{t('usernameLabel')}</Label>
  482. <Input
  483. id="editUsername"
  484. type="text"
  485. placeholder={t('usernamePlaceholder')}
  486. value={editUsername}
  487. onChange={(e) => setEditUsername(e.target.value)}
  488. disabled={isSaving}
  489. />
  490. </div>
  491. <div className="space-y-2">
  492. <Label htmlFor="editEmail">{t('emailLabel')}</Label>
  493. <Input
  494. id="editEmail"
  495. type="email"
  496. value={user?.email || ''}
  497. disabled
  498. className="bg-gray-50"
  499. />
  500. <p className="text-xs text-gray-500">{t('emailCannotModify')}</p>
  501. </div>
  502. <div className="flex space-x-2">
  503. <Button
  504. onClick={handleSaveProfile}
  505. disabled={isSaving}
  506. className="flex items-center"
  507. >
  508. <Save className="h-4 w-4 mr-2" />
  509. {isSaving ? t('saving') : t('save')}
  510. </Button>
  511. <Button
  512. variant="outline"
  513. onClick={handleCancelEdit}
  514. disabled={isSaving}
  515. className="flex items-center"
  516. >
  517. <X className="h-4 w-4 mr-2" />
  518. {t('cancel')}
  519. </Button>
  520. </div>
  521. </div>
  522. </CardContent>
  523. </Card>
  524. )}
  525. <Card>
  526. <CardHeader>
  527. <CardTitle>{t('recentActivity')}</CardTitle>
  528. <CardDescription>
  529. {t('recentActivityDesc')}
  530. </CardDescription>
  531. </CardHeader>
  532. <CardContent>
  533. <div className="space-y-4">
  534. {isLoadingActivities ? (
  535. <div className="text-center py-8">
  536. <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
  537. <p className="mt-2 text-sm text-gray-500">{t('loadingActivities')}</p>
  538. </div>
  539. ) : activities.length > 0 ? (
  540. activities.map((activity) => (
  541. <div key={activity.id} className="flex items-center space-x-4 p-4 bg-gray-50 rounded-lg">
  542. <div className="flex-shrink-0">
  543. {getActivityIcon(activity.type)}
  544. </div>
  545. <div className="flex-1 min-w-0">
  546. <div className="flex items-center justify-between">
  547. <p className="text-sm font-medium text-gray-900 truncate">
  548. {formatActivityDescription(activity)}
  549. </p>
  550. {activity.creditAmount && (
  551. <span className={`text-sm font-semibold ml-2 ${
  552. activity.creditAmount > 0
  553. ? 'text-green-600'
  554. : 'text-red-600'
  555. }`}>
  556. {activity.creditAmount > 0 ? '+' : ''}{activity.creditAmount} {locale === 'zh' ? '积分' : 'credits'}
  557. </span>
  558. )}
  559. </div>
  560. <div className="flex items-center justify-between mt-1">
  561. <p className="text-xs text-gray-500">
  562. {getActivityTypeText(activity.type)}
  563. </p>
  564. <p className="text-xs text-gray-400">
  565. {formatDate(activity.createdAt)}
  566. </p>
  567. </div>
  568. </div>
  569. </div>
  570. ))
  571. ) : (
  572. <div className="text-center text-gray-500 py-8">
  573. <Coins className="h-12 w-12 text-gray-300 mx-auto mb-3" />
  574. <p className="text-sm">{t('noActivities')}</p>
  575. <p className="text-xs text-gray-400 mt-1">{t('noActivitiesDesc')}</p>
  576. </div>
  577. )}
  578. </div>
  579. </CardContent>
  580. </Card>
  581. </div>
  582. </div>
  583. )
  584. }